Runkeeper read your contacts as per their own website. When you install the app, they store your install in their database with your number or something that was originated from your number (encrypted version for example).
When you want to know who has the app, they do the same encryption on your contacts numbers, and correlate with what they have.
For example:
User1 : [Phone 1234]
User2 : [Phone 2341]
User3 : [Phone 3412]
User1 installs the app, Runkeeper stores the sha1 of his number, salted or not, in their database:
Runkeeper database of installs:
7110eda4d09e062aa5e4a390b0a572ac0d2c0220
User2 installs the app, Runkeeper stores the sha1 of his number in their database:
Runkeeper database of installs:
7110eda4d09e062aa5e4a390b0a572ac0d2c0220 <-user1
52c88b165a3a614a5e3ceac0074bad92d5bb1c0a <-user2
When User2 loads the activity that shows him his contacts that are using the app, Runkeeper reads your contacts then for each contact they contact their database and correlate without ever sending your phone number or your contacts outside.
Disclaimer
This is an example approach, I have no idea if this is the way they're doing it. It's overly simplified on purpouse for sake of clarity.
Follow-up
While it may be true that you never provided the phone number to the app, it doesn't necessarily mean that it doesn't have it already. Test yourself the code in this answer to check whether or not it's readable
Apparently op's Settings / About Phone / Status / My phone Number
is empty, as is the google account phone number, which would be another way of accessing it.